Skip to content

feat(workspace): logo upload + brand colors in workspace settings#941

Open
mdubel wants to merge 5 commits into
mainfrom
feat/workspace-branding
Open

feat(workspace): logo upload + brand colors in workspace settings#941
mdubel wants to merge 5 commits into
mainfrom
feat/workspace-branding

Conversation

@mdubel

@mdubel mdubel commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

What

Gives organization workspaces a brand identity, configurable from Workspace settings → Profile → Branding (owners/admins only):

  • Logo upload — PNG/JPG/SVG/WebP/GIF up to ~384 KB. The logo replaces the Lucide icon everywhere the workspace is shown (sidebar switcher, workspace home header, workspace picker, profile "Workspaces" list) and falls back to the icon when unset.
  • Main + auxiliary brand colors — two color pickers with a live preview, Save, and Reset-to-default. The main color drives --primary (buttons, links, active nav, focus rings), the auxiliary drives --accent, recoloring the UI in both light and dark mode with an auto-contrast foreground.

How

Both ride the existing PATCH /api/namespaces/:handle path — no new routes. Empty string clears any field.

  • Logo is stored inline as a size-capped base64 data: URL on the workspace record, so it travels with the already-authenticated namespaces.get / users.me payloads and renders via a plain <img src> — no auth-in-<img> problem, no blob-store serving route, no proxy carve-out. The base64 blob is stripped from the audit snapshot.
  • Colors are #rrggbb hex on the same record. A small client component (brand-theme.tsx) converts them to HSL triples (brand-color.ts) and injects a <style> overriding --primary/--accent (+ --ring/--sidebar-* mirrors). It emits both :root{} and .dark{} so the override wins over next-themes' .dark token block.

Full chain: schema → Postgres (migration 0029) + in-memory double → contract → handler → client/hook → settings UI + every display site → MeNamespace so the sidebar sees it.

Tests

pnpm typecheck clean; new/extended tests green:

  • Contract: brand-color hex + logo data-URL accept/reject/clear.
  • Handler: logo + colors mapping, clearing, and that the base64 logo stays out of the audit snapshot.
  • Repo parity: new columns round-trip (in-memory; Postgres branch runs with TEST_DATABASE_URL).
  • Unit: hexToHslTriple / contrast picker.

Existing workspaces (no logo/colors) render byte-identically — BrandTheme returns null and every logo site falls back to the current icon path.

Notes for reviewers

  • Deploy needs db:migrate — migration 0029 adds logo, brand_primary_color, brand_accent_color to workspaces (all nullable).
  • The auxiliary color maps to --accent, which shadcn also uses for subtle hover surfaces (hover:bg-accent) — so a very saturated auxiliary color tints hover states app-wide, especially in dark mode. This matches the "colors used everywhere" intent; happy to scope it to badges/highlights only if preferred (one-line change to which tokens it overrides).

🤖 Generated with Claude Code

Organization workspaces can now set a brand identity from Workspace
settings: an uploadable logo image and a main + auxiliary brand color.

- Logo: stored inline as a size-capped base64 data URL on the namespace
  record (no new upload/serving route — it travels with the existing
  authenticated namespaces.get / users.me payloads and renders via a
  plain <img>). Replaces the Lucide icon in the sidebar switcher,
  workspace header, and workspace picker; falls back to the icon when
  unset.
- Colors: #rrggbb hex on the namespace record, converted to HSL triples
  and injected as a <style> that overrides the --primary / --accent
  design tokens app-wide (plus --ring / --sidebar-* mirrors, with a
  contrast-picked foreground), applied in both light and dark.

All three fields flow through the existing PATCH /api/namespaces/:handle
(empty string clears). The base64 logo is kept out of the audit snapshot.

Covers schema -> Postgres (migration 0029) + in-memory double ->
contract -> handler -> client/hook -> settings UI + every display site,
plus MeNamespace so the sidebar sees it. Tests: contract, handler,
repo parity, and a hex->HSL unit test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
mediforce Ignored Ignored Preview Jul 20, 2026 8:37pm

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds workspace branding (organization workspaces) by extending the existing namespace/workspace record with an inline logo (data: URL) plus primary/accent brand colors, and wiring that through the API, repositories, and UI so the logo/colors render consistently across workspace surfaces.

Changes:

  • Extend workspace/namespace schema + persistence (Postgres + repo) to store logo, brandPrimaryColor, and brandAccentColor, including a migration.
  • Update API contracts/handlers and UI mutation hooks to accept/patch/echo the new fields (and keep the audit snapshot small for logos).
  • Add UI rendering + settings controls: logo upload/removal, color pickers, and runtime token overrides via BrandTheme.

Reviewed changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
packages/platform-ui/src/lib/brand-color.ts Pure helpers to convert #rrggbb to HSL triples and choose readable foregrounds.
packages/platform-ui/src/lib/tests/brand-color.test.ts Unit tests for hex→HSL conversion and foreground selection.
packages/platform-ui/src/hooks/use-namespace-mutations.ts Optimistic updates + cache updates for logo and brand colors.
packages/platform-ui/src/components/brand-theme.tsx Client-side token override injector for --primary/--accent (+ mirrors).
packages/platform-ui/src/components/app-shell.tsx Apply BrandTheme and render logos in the namespace switcher.
packages/platform-ui/src/app/workspace-selection/page.tsx Render org logo in workspace selection cards when present.
packages/platform-ui/src/app/(app)/[handle]/settings/page.tsx Add branding section (logo upload + color pickers) in workspace settings.
packages/platform-ui/src/app/(app)/[handle]/page.tsx Render workspace logos in profile/workspace listings and header.
packages/platform-infra/src/postgres/schema/workspace.ts Add new columns to Drizzle workspace table schema.
packages/platform-infra/src/postgres/repositories/namespace-repository.ts Map new fields to/from DB rows and update mutations.
packages/platform-infra/src/postgres/migrations/meta/_journal.json Register migration 0029_workspace_branding.
packages/platform-infra/src/postgres/migrations/0029_workspace_branding.sql Add logo, brand_primary_color, brand_accent_color columns.
packages/platform-infra/src/postgres/tests/namespace-parity.test.ts Ensure repo parity/round-trip includes branding fields.
packages/platform-core/src/schemas/namespace.ts Add Zod schemas for logo + brand colors and extend NamespaceSchema.
packages/platform-core/src/schemas/index.ts Re-export branding schemas/constants.
packages/platform-core/src/interfaces/namespace-repository.ts Extend NamespaceUpdates to include branding fields.
packages/platform-core/src/index.ts Re-export branding schemas/constants at package root.
packages/platform-api/src/handlers/users/get-me.ts Include branding fields in users.me namespace list output.
packages/platform-api/src/handlers/namespaces/namespace-mutations.ts Accept branding updates; omit base64 logo from audit snapshot.
packages/platform-api/src/handlers/namespaces/tests/namespace-mutations.test.ts Tests for updating/clearing branding and audit snapshot behavior.
packages/platform-api/src/contract/users.ts Extend MeNamespaceSchema to include branding fields.
packages/platform-api/src/contract/namespaces.ts Extend update schema to accept logo + brand color fields.
packages/platform-api/src/contract/tests/namespaces.test.ts Contract tests for accepting/rejecting branding inputs.
CHANGELOG.md Add Unreleased entry documenting workspace branding feature.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +15 to +22
/**
* A workspace logo, stored inline as a base64 `data:` image URL (or `""` to
* clear). Kept on the workspace record rather than the blob store so it travels
* with the already-authenticated `namespaces.get` / `users.me` payloads and
* renders via a plain `<img src>` — no separate authenticated fetch. The
* ~256 KiB char cap keeps that payload small; logos should be optimised
* (SVG/PNG) assets, not photos.
*/
Comment on lines +315 to +325
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
try {
await updateNamespace.mutateAsync({ handle, logo: dataUrl });
} catch {
setLogoError('Failed to upload logo.');
}
Comment on lines +57 to +60
const block = declarations.join(';');
const css = `:root{${block}}\n.dark{${block}}`;

return <style dangerouslySetInnerHTML={{ __html: css }} />;
Comment on lines +25 to +29
avatarUrl: z.string().url().optional(),
icon: z.string().optional(),
logo: z.string().optional(),
brandPrimaryColor: z.string().optional(),
brandAccentColor: z.string().optional(),
Griphu and others added 2 commits July 20, 2026 14:59
# Conflicts:
#	packages/platform-infra/src/postgres/migrations/meta/_journal.json
Self-review found three blockers in the branding PR.

Dark mode: BrandTheme emitted one identical declaration block for :root
and .dark, so `--accent` — the `hover:bg-accent` surface, a near-black
`217.2 32.6% 17.5%` in dark — was replaced by a fully saturated brand
color, and `--primary` lost the lightness lift its dark default applies.
The two modes now derive separately: light passes the brand color
through (its `--accent` default is already a saturated amber), dark
lifts `--primary` to >=55% lightness and clamps `--accent` into the band
its default occupies. `brandTokenTriples()` derives the foreground from
the adjusted color, so a clamped hover surface gets legible text. The
`--sidebar-accent*` overrides are dropped — zero usages.

Payload: `users.me` carries one logo per membership, so a 512 KiB cap
meant multi-MB responses on every app-shell mount. Rather than shrink
what users may upload, uploads are now downscaled to 256px on the
longest edge before encoding (SVGs pass through), so stored logos land
in the single-digit KB range and the cap is a backstop.

Coverage: adds the L3 API journey the feature was missing — round-trip,
clearing, size cap, malformed hex, and unauthenticated writes — plus
unit tests for the token derivation. MeNamespace fields now use the
WorkspaceLogoSchema / BrandColorSchema this PR already exports instead
of bare z.string().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Griphu

Griphu commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Self-review follow-up — three blockers fixed (9a3ec7d)

Ran /code-review on the branch; it surfaced two confirmed regressions and a missing-coverage gate. All three are addressed below. Verified: typecheck exit 0, unit suite 1732 passed / 5 skipped (159 files), API E2E 69 passed.

1. Brand tokens were flattened across light and dark — regression

BrandTheme emitted one identical declaration block for both :root and .dark. Two consequences:

  • --accent is the hover:bg-accent surface (38 usages, all hover states). Its dark default is the near-black 217.2 32.6% 17.5%, so writing a saturated brand color there made every hover/selected row in the app glow.
  • --primary loses the lightness lift its dark default applies (172 66% 32% light → 172 60% 60% dark), so any dark brand color became unreadable on a dark background.

Worth noting for reviewers: this was only broken in dark mode. Light mode's --accent default is already a deliberate saturated amber (38 92% 50%), so passing the brand color through there is continuous with existing design intent, not a break.

:root and .dark now derive separately. Light passes the brand color through; dark lifts --primary to ≥55% lightness and clamps --accent to maxSaturation: 33, lightness 14–20% — the band its default occupies. A hover state should hint at the brand, not compete with the content on top of it.

New brandTokenTriples() in lib/brand-color.ts derives the readable foreground from the adjusted color rather than the input. This matters: amber alone reads as "light" and gets dark text, but clamped to a 20%-lightness surface it needs white — deriving from the input would have produced unreadable labels.

Also dropped the --sidebar-accent / --sidebar-accent-foreground overrides: zero usages in the codebase.

2. users.me payload grew unbounded — regression

getMe returns every namespace the user belongs to, each now carrying a full base64 logo, on an uncacheable endpoint hit at every app-shell mount. At the original 512 KiB cap that's multi-MB for a user in several branded workspaces.

The cap alone was the wrong lever — it's an aggregation problem, and shrinking the cap taxes single-workspace users who'd never trigger it. Instead, uploads are downscaled to 256px on the longest edge before encoding (lib/logo-image.ts), so stored logos land in the single-digit KB range and the 512 KiB cap becomes a backstop. SVGs pass through untouched — rasterising a vector to hit a pixel budget makes it worse on high-DPI screens, and they're already tiny.

Side benefit: dropping in a multi-megabyte brand-kit PNG now just works instead of erroring.

3. Missing L3 coverage

Added e2e/api/namespace-branding.journey.ts: round-trip, "" clears, size cap rejected, malformed hex rejected, unauthenticated write rejected. The last three are what justify L3 — they exercise the route adapter's Zod validation and the auth middleware, which the handler unit tests bypass. Plus 6 unit tests for the token derivation.

Also folded in: MeNamespace fields now use the WorkspaceLogoSchema / BrandColorSchema this PR already exports, instead of bare z.string().

Known gap

lib/logo-image.ts has no test coverage — it's canvas / FileReader / Image, so it needs jsdom mocks or an L4 journey. Typecheck proves it compiles; nothing proves it produces a correct image. Happy to add coverage here if a reviewer wants it before merge.

Not addressed (review's "should fix", deliberately out of scope)

Org-only rule is still UI-only in settings/page.tsx rather than enforced in updateNamespace; five hand-rolled logo-or-icon renders could collapse into a WorkspaceAvatar (four skip ImgWithFallback, so a corrupt data URL shows a broken-image glyph); a few implicit-truthy checks and a banner comment; preview swatches hardcode text-white where readableForegroundTriple() exists.
🤖 Generated with Claude Code

Closes the known gap on #941: fileToLogoDataUrl had no coverage because
it leans on canvas / Image, neither of which jsdom implements.

Stubs Image decoding and the 2d context; FileReader is jsdom's real one,
so the data: URL read out of the File is genuine. Covers SVG passthrough,
downscale geometry in both orientations, the never-upscale guard, both
size caps, an undecodable file, and the no-context fallback.

Verified non-vacuous by mutation: dropping the upscale guard, the SVG
passthrough, or the post-encode cap each fails the suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Griphu

Griphu commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Known gap closed — logo-image.ts now covered

Added src/lib/__tests__/logo-image.test.ts (c54449d). L1, runs in the default pnpm test suite.

jsdom has no canvas backend and decodes no bitmaps, so Image and the 2d context are stubbed. FileReader is jsdom's real one — the data: URL read out of the File is genuine, which is what makes the SVG-passthrough assertion meaningful (it decodes the base64 back and compares to the source markup).

Ten cases: PNG re-encode, downscale geometry landscape + portrait, the never-upscale guard, source-byte cap, post-encode schema cap (raster and SVG), an undecodable file, and the getContext() === null fallback.

Verified non-vacuous by mutation — each of these breaks the suite:

Mutation Failures
drop the never-upscale guard 1
remove SVG passthrough 2
remove the post-encode size cap 2

pnpm typecheck clean; src/lib suite 241/241 green.

What this still doesn't prove: that the bytes are a correct image. drawImage/toDataURL are stubs, so the test pins the decision logic — which dimensions, which branch, which error — not pixel output. Real rasterisation needs the canvas native dep or an L4 journey with a fixture PNG; both felt disproportionate for a 79-line module whose risk is branch selection. Say the word if you'd rather have the L4.

@Griphu

Griphu commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Spec correction — PR description is stale

Two numbers in the description above no longer match the branch; noting them here rather than rewriting the description so the original intent stays visible.

Claim in description Actual on branch
logo up to ~384 KB no such bound exists. WORKSPACE_LOGO_MAX_CHARS = 512 * 1024 (512 KiB) caps the encoded data URL; LOGO_SOURCE_MAX_BYTES = 10 * 1024 * 1024 gates the source file before encoding
migration 0029 the file is 0030_workspace_branding.sql

Neither is a behaviour change — the description was written against an earlier revision. The 512 KiB cap is a backstop, not the mechanism that keeps payloads small: rasters are downscaled to LOGO_MAX_EDGE_PX before encoding, so stored logos land in the single-digit KB range.

Also landed since the description was written:

  • Branding is now enforced organization-only in updateNamespace, not just hidden in the settings UI. icon / logo / brand colors on a personal namespace return 409 precondition_failed. This narrows the public REST/CLI surface: mediforce namespace update --handle <personal> --icon Foo previously succeeded and wrote a field nothing rendered. Accepted deliberately.
  • mediforce namespace update gained --logo, --brand-primary-color, --brand-accent-color, closing the dogfood gap where three handler-supported fields were UI-only.
  • The five hand-rolled logo-or-icon renders collapsed into one WorkspaceAvatar; four of them previously had no image-error fallback.
  • Animated GIFs flatten to their first frame (PNG re-encode carries no animation) — now documented in logo-image.ts and surfaced in the upload hint text.

Test-fixture note worth flagging for reviewers: TEST_ORG_HANDLE is seeded as type: 'personal' despite the name, so the branding journeys were exercising the personal path and never covered the organization path they claimed to. The org-only rule surfaced this. The journey now seeds its own organization per test; e2e/helpers/constants.ts documents the trap. Renaming the constant (147 usages / 44 files) was left out of this PR as too large.

🤖 Generated with Claude Code

Follow-ups from review of the branding PR.

Enforce org-only branding in `updateNamespace` rather than only hiding it
in the settings UI: a personal namespace renders its linked user's avatar,
so `icon` / `logo` / brand colors have nowhere to show and are rejected
with `409 precondition_failed` naming the offending fields. This narrows
the public surface — `mediforce namespace update --handle <personal>
--icon Foo` previously succeeded and wrote a field nothing rendered.
Accepted deliberately; no in-repo caller or user flow hits it
(`handleSaveProfile` sends only displayName/bio, the icon picker is
already org-gated).

Collapse five hand-rolled logo-or-icon renders into one `WorkspaceAvatar`.
Four of them had no image-error path, so a corrupt stored logo showed a
broken-image glyph; all five now fall back to the workspace icon. The
component keys its error state on the failing source rather than a
boolean, so a re-upload retries instead of latching.

Add `--logo` / `--brand-primary-color` / `--brand-accent-color` to
`mediforce namespace update`, closing the dogfood gap where three
handler-supported fields were UI-only. `--logo` takes a file path (or a
`data:` URL). The CLI has no browser canvas, so it does not downscale —
an oversized source is rejected by the cap rather than silently resized.

Also: derive brand-preview swatch text via `readableForegroundTriple`
instead of hardcoded `text-white`; make implicit-truthy checks explicit;
guard both modes in `BrandTheme`; document that animated GIFs flatten to
their first frame (the PNG re-encode carries no animation).

Test-fixture fix: `TEST_ORG_HANDLE` is seeded `type: 'personal'` despite
the name, so the branding journeys were exercising the personal path and
never covered the organization path they claimed to — the org-only rule
surfaced this. The journey now seeds its own org per test (a shared handle
raced its own afterEach clear under parallel workers); `constants.ts`
documents the trap. Renaming the constant (147 usages / 44 files) is left
out as too large for this PR.

Verified: platform-ui + cli tsc clean, 52/52 handler, 471/471 UI unit,
286/286 CLI, 70/70 API E2E.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants